Skip to content

Fix use-after-free race condition in C extension for free-threaded CPython (3.13t+) - #1317

Open
rodrigobnogueira wants to merge 19 commits into
aio-libs:masterfrom
rodrigobnogueira:fix-free-threaded-race-condition
Open

Fix use-after-free race condition in C extension for free-threaded CPython (3.13t+)#1317
rodrigobnogueira wants to merge 19 commits into
aio-libs:masterfrom
rodrigobnogueira:fix-free-threaded-race-condition

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Member

Description

This PR fixes a critical memory-safety race condition (Use-After-Free) that results in a process segmentation fault or abort (stack smashing) when iterating over and modifying a MultiDict concurrently in CPython free-threaded mode (3.13t+).

Context

When running without the GIL, iterating over md.keys()/md.items() or view objects could safely yield memory pointers pointing to MultiDictObject->keys. However, if the dictionary resized concurrently using _md_resize(), the keys table was reallocated and immediately free()d, leaving concurrent iterators reading stale heap pointers. This explicitly leaked and corrupted process memory resulting in an ASAN/SEGV error on the READ inside the iterator buffers.

Proposed Approach

To safely resolve this while adhering to Python 3.13 free-threaded lock mechanisms, we matched CPython's own dict concurrent locking pattern. This involves adding Py_BEGIN_CRITICAL_SECTION(self) / Py_END_CRITICAL_SECTION() at every public-facing entry point in the C extension. The pythoncapi_compat.h shim already natively skips these macros on pre-3.13 monolithic GIL designs, making this 100% zero-overhead everywhere except 3.13t.

  • Wrapped all major method slots in _multidict.c (__getitem__, __setitem__, __delitem__, add, getall, pop, copy, etc.),
  • Wrapped all iterator block functions dynamically unlocking inside iter.h,
  • Fixed finder operations correctly propagating to cs_done for safety unloads in views.h.

Verification

  1. Concurrency Testing (test_free_threading.py): Added a new threading test module that heavily threads CIMultiDict adding and deleting inputs while natively reading iterations concurrently.
  2. Safety Assertions: Validated that upon hash resizing, the active pointers dynamically trip into finder->version != finder->md->version successfully, naturally abandoning the iteration and surfacing standard RuntimeError('MultiDict is changed during iteration') without throwing a C segmentation fault! This matches native single-threaded Py-behavior correctly.
  3. No Native Regressions: Formatted files by clang-format and confirmed 100% test coverage matches the baseline branch over python3.13t.

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Apr 12, 2026
Comment thread tests/test_free_threading.py Fixed
Comment thread tests/test_free_threading.py Fixed
@codspeed-hq

codspeed-hq Bot commented Apr 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 242 untouched benchmarks


Comparing rodrigobnogueira:fix-free-threaded-race-condition (ad8f6fb) with master (41c1b91)

Open in CodSpeed

@rodrigobnogueira
rodrigobnogueira force-pushed the fix-free-threaded-race-condition branch from ff45477 to 0b04769 Compare April 12, 2026 05:29
@rodrigobnogueira
rodrigobnogueira marked this pull request as draft April 12, 2026 05:33
@rodrigobnogueira
rodrigobnogueira force-pushed the fix-free-threaded-race-condition branch from 0af9e6a to e1cbca0 Compare April 12, 2026 15:10
@rodrigobnogueira

This comment was marked as outdated.

@codecov

codecov Bot commented Apr 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.86%. Comparing base (2aed5c2) to head (b3b4434).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1317   +/-   ##
=======================================
  Coverage   99.86%   99.86%           
=======================================
  Files          28       29    +1     
  Lines        3627     3666   +39     
  Branches      265      271    +6     
=======================================
+ Hits         3622     3661   +39     
  Misses          3        3           
  Partials        2        2           
Flag Coverage Δ
CI-GHA 99.86% <100.00%> (+<0.01%) ⬆️
pytest 99.86% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rodrigobnogueira rodrigobnogueira changed the title Fix memory safety race condition in CPython 3.13 free-threaded mode Fix use-after-free race condition in C extension for free-threaded CPython (3.13t+) Apr 12, 2026
@rodrigobnogueira
rodrigobnogueira force-pushed the fix-free-threaded-race-condition branch from 62dce51 to 8121e70 Compare April 13, 2026 03:27
@rodrigobnogueira
rodrigobnogueira marked this pull request as ready for review April 13, 2026 03:49

@Vizonex Vizonex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good surprised this wasn't seen with the sniffer from #1306. Be sure you update your branches if you haven't since #1314 Got merged which I wrote to help with broken coverage but other than that I have no suggestions.

@Vizonex

Vizonex commented Apr 28, 2026

Copy link
Copy Markdown
Member

@rodrigobnogueira it's been 2 weeks so I'll see if we can get someone else in here to merge since I don't have that authorization to do so yet. for now I recommend staying updated with the main branch.

Comment thread CHANGES/1317.bugfix.rst Outdated
Comment thread tests/test_free_threading.py Outdated
@Vizonex Vizonex added the free-threading FT-related discussion, issue or PR label May 1, 2026
Add per-object locking via Py_BEGIN_CRITICAL_SECTION at all public-facing
entry points in the C extension to prevent use-after-free crashes when
iterating and mutating a MultiDict concurrently.

The root cause is _md_resize() reallocating md->keys without any
synchronization, causing concurrent iterators to access freed memory.
This fix wraps all public methods in Py_BEGIN_CRITICAL_SECTION(self),
matching CPython's own dict locking model. On GIL builds the macros
are no-ops (via pythoncapi_compat.h).

Adds tests/test_free_threading.py with a concurrent stress test that
previously crashed with SIGSEGV and now completes cleanly.
On non-free-threaded builds, Py_END_CRITICAL_SECTION() expands to just '}'.
When preceded by a goto label like 'cs_done:', this creates a C23 extension
that clang -Werror rejects. Adding an empty statement (;) after the label
makes it valid C11.
In debug builds, Py_BEGIN_CRITICAL_SECTION can suspend during Python
calls (e.g. __eq__ in md_calc_identity), allowing another thread to
enter. The md_replace finder pattern uses hash=-1 markers internally,
and ASSERT_CONSISTENT inside md_finder_cleanup catches this temporary
inconsistency, calling abort(). Remove del operations from the writer
loop since simple set+read is sufficient to exercise the core resize
race fix.
Comment thread tests/test_free_threading.py Fixed
@rodrigobnogueira
rodrigobnogueira force-pushed the fix-free-threaded-race-condition branch 2 times, most recently from b9ecbaf to 1d70349 Compare May 2, 2026 02:22
- Use `any_multidict_class` fixture instead of manual `@pytest.mark.parametrize`
- Use modern type annotations (`type[]` instead of `Type[]`, `|` instead of `Union`)
- Add explanatory comments to empty except clauses (CodeQL feedback)
- Use RST roles in changelog (`:class:`, `:c:func:`) for highlighted identifiers
- Remove manual pure-python skip (fixture handles both variants)
- Use `MutableMultiMapping` for internal type hints
@rodrigobnogueira
rodrigobnogueira force-pushed the fix-free-threaded-race-condition branch from 1d70349 to 469dda9 Compare May 2, 2026 02:36
@rodrigobnogueira

Copy link
Copy Markdown
Member Author

While fixing the C extension race condition, our test suite (now running the any_multidict_class fixture against both implementations) uncovered a related but separate thread-safety bug in _multidict_py.py on free-threaded CPython (3.13t).

Root cause: __setitem__ temporarily sets e.hash = -1 as a sentinel marker while traversing entries, relying on restore_hash() to clean up afterwards. Under free-threaded CPython, a concurrent thread can enter _add_with_hash_resizebuild_indices(update=False) in that window, where it hits:

assert hash_ != -1  # line 543

...and raises AssertionError because it observed the in-progress -1 sentinel from the other thread.

The fix for the C extension in this PR uses Py_BEGIN_CRITICAL_SECTION, but the pure-Python implementation has no equivalent synchronization. Fixing it properly would require adding a threading.Lock to MultiDict.

I've skipped the test for the pure-Python variant for now to unblock this PR.

@rodrigobnogueira

Copy link
Copy Markdown
Member Author

Error showed up in CI (traceback from Thread B):

  File "_multidict_py.py", line 881, in __setitem__
    self._add_with_hash(_Entry(hash_, identity, key, value))
       ↓ (usable <= 0, triggers resize)
  File "_multidict_py.py", line 1088, in _add_with_hash
    self._resize((self._used * 3 | _HtKeys.MINSIZE - 1).bit_length(), False)
       ↓
  File "_multidict_py.py", line 1083, in _resize
    newkeys.build_indices(update)
       ↓
  File "_multidict_py.py", line 543, in build_indices
    assert hash_ != -1
AssertionError

Two concurrent __setitem__ calls race:

  • Thread A is updating an existing key — it sets e.hash = -1 (line 874) as a sentinel while iterating, and has not yet called restore_hash().
  • Thread B is inserting a new key — it enters the not found branch (line 880–881), calls _add_with_hash_resizebuild_indices(update=False), which iterates the same shared entries and hits Thread A's -1 sentinel → AssertionError.

@rodrigobnogueira
rodrigobnogueira requested a review from webknjaz May 2, 2026 03:28
@Vizonex

Vizonex commented May 2, 2026

Copy link
Copy Markdown
Member

See #1327

@Dreamsorcerer

Copy link
Copy Markdown
Member

I'm unclear how much we should be concerned with such issues. The entire point of aio-libs is to provide asyncio libraries, so threading issues shouldn't be relevant. I'd rather not start introducing threading locks into our libraries..

@Vizonex

Vizonex commented May 2, 2026

Copy link
Copy Markdown
Member

I'm unclear how much we should be concerned with such issues. The entire point of aio-libs is to provide asyncio libraries, so threading issues shouldn't be relevant. I'd rather not start introducing threading locks into our libraries..

@Dreamsorcerer You have to remember to that multidict is used in other libraries or projects besides our own. An example that I have is litestar however I'm with you on questioning weather or not thread locks should be considered acceptable or unacceptable however.

@rodrigobnogueira

Copy link
Copy Markdown
Member Author

I'm unclear how much we should be concerned with such issues. The entire point of aio-libs is to provide asyncio libraries, so threading issues shouldn't be relevant. I'd rather not start introducing threading locks into our libraries..

Adding threading.Lock to the pure-Python fallback would add overhead for all users to solve a problem that is probably an edge case. Would it be acceptable to emit a RuntimeWarning at import time only when both conditions are true: running on free-threaded CPython (GIL disabled) AND the C extension is not available?

@Dreamsorcerer

Copy link
Copy Markdown
Member

Adding threading.Lock to the pure-Python fallback would add overhead for all users to solve a problem that is probably an edge case. Would it be acceptable to emit a RuntimeWarning at import time only when both conditions are true: running on free-threaded CPython (GIL disabled) AND the C extension is not available?

We're talking about just iterating and modifying the same multidict, right? Can just mention that this shouldn't be done in a threaded situation. Maybe issue a warning.

@rodrigobnogueira

Copy link
Copy Markdown
Member Author

Adding threading.Lock to the pure-Python fallback would add overhead for all users to solve a problem that is probably an edge case. Would it be acceptable to emit a RuntimeWarning at import time only when both conditions are true: running on free-threaded CPython (GIL disabled) AND the C extension is not available?

We're talking about just iterating and modifying the same multidict, right? Can just mention that this shouldn't be done in a threaded situation. Maybe issue a warning.

Added a RuntimeWarning in init.py that fires only when the pure-Python fallback is used on free-threaded CPython (GIL disabled + C extension unavailable).

@Vizonex

Vizonex commented Jun 23, 2026

Copy link
Copy Markdown
Member

Are we still planning to move forward with these free-threaded PRs at all or will these be closed?

Master adopted the PLC0415 lint rule (top-level imports) after this
branch was created; updating the branch surfaced it on the in-function
import in the writer helper.
@rodrigobnogueira

Copy link
Copy Markdown
Member Author

pre-commit surfaced the new PLC0415 rule, fixed in 4079a3e.

@rodrigobnogueira

Copy link
Copy Markdown
Member Author

Are we still planning to move forward with these free-threaded PRs at all or will these be closed?

IMO this solves the issue, and the main cost is how many functions it has to touch: every entry point that reads md->keys needs the critical section, the same way CPython's dict does it. The sections are no-ops on regular builds, so only free-threaded builds pay.

I think it deserves fixing: this is memory unsafety, not a logical race. A pure-Python user on 3.13t can segfault the process, where CPython's dict raises RuntimeError. The lighter alternative is documenting it as unsupported and keeping only the RuntimeWarning, but that leaves a crash reachable from Python code.

Reference Py_BEGIN_CRITICAL_SECTION through the Sphinx c:macro role
(resolved via intersphinx, same as the existing c:func usage in
CHANGES.rst) and name the internal buffer md->keys as it appears in
the C code.
@devdanzin

Copy link
Copy Markdown

This is the right approach — mirroring CPython dict's per-object critical sections at every entry point, no-op on the default build. Two follow-ups:

1. The cross-object paths lock only self. multidict_tp_richcompare correctly uses Py_BEGIN_CRITICAL_SECTION2(self, other_md), but multidict_extend / update / merge-from-another-multidict are wrapped Py_BEGIN_CRITICAL_SECTION(self) only — and md_update_from_ht / md_clone_from_ht read the source other->keys. A thread mutating other while another does a.update(other) still races the read. These want CRITICAL_SECTION2(self, other) too. (multidict_copy is fine — there self is the source.)

2. A few of the crashes are reachable single-threaded, so they survive the critical sections. A critical section is a no-op on the default (GIL) build and doesn't guard same-thread reentrancy, so these need the function bodies fixed regardless of locking — they're borrowed-pointer-across-callback bugs, not pure data races:

  • md_update_from_ht caches htkeys_entries(other->keys) and holds borrowed entry->key/value across a resize / key.lower(). Reproduces on a plain debug and ASan build, no threads:

    from multidict import MultiDict
    d = MultiDict([(f"k{i}", i) for i in range(64)])
    d.extend(d)   # self-alias: growing d resizes/frees d->keys mid-copy -> SIGSEGV
  • md_update_from_seq caches the list size once, then a CI key's .lower() can shrink the list → PyList_GET_ITEM past the end (OOB). Also reproduces single-threaded.

  • md_eq holds lft_entries/rht_entries across PyObject_RichCompareBool; a value's __eq__ that mutates a dict dangles the cached base (reentrant even under the GIL).

The fix for these is in the bodies: re-fetch the entries base after any call that can mutate/resize (the md_next/md_find_next re-fetch pattern already used elsewhere), snapshot the size, and for the self-alias case detect other == self. I have runnable reproducers for each and am glad to open them as separate issues (they overlap the C-extension report #1306). None of this blocks the concurrency fix here — just flagging that the critical sections alone won't close these particular crashes.

@espressolee

Copy link
Copy Markdown

Doing a free-threading pass over multidict I built a tree with this PR, #1327 and #1379 all merged, and one path this PR does not reach turned up. Flagging it here rather than opening an issue, since it belongs to the work you are already doing.

_multidict_extend (_multidict.c:81) calls md_update_from_ht(self, other, op) with no critical section. md_update_from_ht then takes a raw pointer into the other multidict's table and walks it:

entry_t *entries = htkeys_entries(other->keys);
Py_ssize_t nentries = other->keys->nentries;

for (pos = 0; pos < nentries; pos++) {
    entry_t *entry = entries + pos;

The four Py_BEGIN_CRITICAL_SECTION2 uses this PR adds are all in multidict_tp_richcompare, so on the extend path other is never locked. A concurrent insert into other can resize and free the array entries points at.

Measured on 3.14.0rc1t with all three PRs applied — 8 threads running MultiDict().extend(other), 3 mutating other:

original                                    SIGSEGV 12/12
control: no mutator                         clean 12/12
control: mutator touches a decoy multidict  clean 12/12
control: md->is_ci == other->is_ci          SIGSEGV 11, SIGTRAP 1
GIL build, identical script                 clean 12/12

The last control is the one that places it: with matching is_ci, recalc_identity is false and no Python runs inside the loop, and it still crashes. So this is not a borrow-across-Python problem — it is the _md_resize race from devdanzin's #1321 cluster 3, reachable through a path this PR leaves unprotected. Py_BEGIN_CRITICAL_SECTION2(self, other) around the md_update_from_ht calls looks like it would close it, but you know the locking design here better than I do.

Not reporting this as a separate finding — it is the race already documented, not a new one. Reproducer available if useful.

md_update_from_ht() takes a raw entry_t pointer into the source
multidict's table and walks it. The destination was locked by the
calling entry point, but the source never was, so a concurrent insert
could resize it and free the array mid-walk. On a free-threaded build
that is a reliable segfault, reproduced 12/12 on 3.14.6t with eight
threads extending while three mutate the source. It still crashes when
both sides share is_ci, which rules out the borrow-across-Python path
and places it on _md_resize.

Critical sections are not reentrant, so the second lock cannot be taken
inside md_update_from_ht(); the caller already holds the destination.
The constructors, extend(), update() and merge() now take
Py_BEGIN_CRITICAL_SECTION2 instead, the way CPython's dict_merge() does.
_multidict_extend_source() is the one predicate deciding whether a
second object is involved, and both the lock and the dispatch use it so
they cannot disagree.

Locking at the entry point also covers md_clone_from_ht(), which
memcpy's the source table and is reached through the same argument.
The entry points resolve the argument to decide whether a second
critical section is needed, and _multidict_extend() was then resolving
it again to dispatch. Pass the resolved pointer down so the type checks
run once, as they did before this branch.

This also makes the guarantee stronger than "both sides use the same
predicate": the lock and the dispatch now use the same pointer, so they
cannot diverge even if the predicate changes later.
The previous commit dropped the hashtable.h paragraph explaining why the
second object is locked at the entry point, so the branch carried the
two-object locking change without the comment that justifies it. Put it
back.

Both copies of the rationale said the lock and the dispatch go through
one shared predicate. Since the resolved pointer is passed down, the
guarantee is stronger than that: the object locked and the object walked
are the same pointer, not two reads that happen to agree. Reword to say
so.

Also hold the mutators until every extender has finished, so the source
keeps being resized for the whole test rather than only until the first
one returns. Still detects the bug 10/10 on the pre-fix build and passes
10/10 on this one.
ASSERT_CONSISTENT() walks the whole hash table, so evaluating it without
the object's lock held is not a stale read but an unsynchronised walk of
a buffer another thread can resize and free. Eighteen of them sat outside
the section that guards the operation they follow, and two of those ran
before the section was even opened. On a debug free-threaded build a
plain getall() against a concurrent __setitem__ aborted every run on
CHECK(entry->hash != -1), observing another thread's transient finder
marker; the same shape also produced a segfault.

Most of the strays duplicated a check the md_* helpers already perform
under the lock, so they are removed rather than moved. getall() keeps
one, moved inside its section, because md_get_all() has none of its own,
and the two in views.h move above Py_END_CRITICAL_SECTION(). The one on
multidict_copy() stays: it asserts a freshly built object that no other
thread can reach yet.

The reader and writer in test_race_condition_iterator_vs_mutation now
also call getall/get/add/popone/setdefault, the entry points whose checks
were unlocked. Nothing in the suite reached them before, which is why the
debug job stayed green. The extended test aborts 8/8 on the previous
build and passes 8/8 on this one.

Also fixes the free-threaded fallback warning, which inferred a
CPython-private attribute from the version number and would raise
AttributeError on any other implementation reporting 3.13+, and reworded
it: MULTIDICT_NO_EXTENSIONS is read with bool(), so setting it to "0"
disables the extension while the old text claimed it was unavailable.

The header comment claimed same-object re-entrancy was the only hazard of
calling Python inside a critical section. A section is also suspended
whenever the holding thread detaches, so a marked entry stays visible to
other threads across the value comparisons in views.h; getall() can drop
a value there on a release build. Documented as a known gap, since
closing it needs the callbacks hoisted out of the marked window.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR free-threading FT-related discussion, issue or PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants